***************************************************************************************************************************
********************************************TABLAS*************************************************************************
***************************************************************************************************************************

***************************************************************************************************************************
***************************************************************************************************************************

admin users*

create table public.admin_users (
  email text not null,
  is_active boolean not null default true,
  created_at timestamp with time zone not null default now(),
  constraint admin_users_pkey primary key (email)
) TABLESPACE pg_default;


***************************************************************************************************************************
***************************************************************************************************************************

code_entries

create table public.code_entries (
  id bigserial not null,
  phone_e164 text not null,
  lot_code text not null,
  product_type text not null,
  coupon_id bigint null,
  created_at timestamp with time zone not null default now(),
  constraint code_entries_pkey primary key (id),
  constraint code_entries_phone_e164_fkey foreign KEY (phone_e164) references users_wa (phone_e164) on delete CASCADE,
  constraint code_entries_product_type_check check (
    (
      product_type = any (array['sachet'::text, 'other'::text])
    )
  )
) TABLESPACE pg_default;

create index IF not exists code_entries_phone_idx on public.code_entries using btree (phone_e164) TABLESPACE pg_default;

create index IF not exists code_entries_phone_lot_idx on public.code_entries using btree (phone_e164, lot_code) TABLESPACE pg_default;

***************************************************************************************************************************
***************************************************************************************************************************

coupons

create table public.coupons (
  id bigserial not null,
  phone_e164 text not null,
  coupon_type text not null,
  created_at timestamp with time zone not null default now(),
  coupon_code text null,
  constraint coupons_pkey primary key (id),
  constraint coupons_phone_e164_fkey foreign KEY (phone_e164) references users_wa (phone_e164) on delete CASCADE,
  constraint coupons_coupon_type_check check (
    (
      coupon_type = any (array['sachet_10'::text, 'direct_other'::text])
    )
  )
) TABLESPACE pg_default;

create index IF not exists coupons_phone_idx on public.coupons using btree (phone_e164) TABLESPACE pg_default;

create unique INDEX IF not exists coupons_coupon_code_key on public.coupons using btree (coupon_code) TABLESPACE pg_default;

create trigger trg_set_coupon_code BEFORE INSERT on coupons for EACH row
execute FUNCTION set_coupon_code_before_insert ();

***************************************************************************************************************************
***************************************************************************************************************************

daily_limits

create table public.daily_limits (
  phone_e164 text not null,
  day date not null,
  coupons_created integer not null default 0,
  constraint daily_limits_pkey primary key (phone_e164, day),
  constraint daily_limits_phone_e164_fkey foreign KEY (phone_e164) references users_wa (phone_e164) on delete CASCADE,
  constraint daily_limits_coupons_created_check check ((coupons_created >= 0))
) TABLESPACE pg_default;

***************************************************************************************************************************
***************************************************************************************************************************

delivery_status_history*

create table public.delivery_status_history (
  id bigserial not null,
  prize_delivery_id bigint not null,
  old_status text null,
  new_status text not null,
  changed_by text null,
  comment text null,
  created_at timestamp with time zone not null default now(),
  constraint delivery_status_history_pkey primary key (id),
  constraint delivery_status_history_prize_delivery_id_fkey foreign KEY (prize_delivery_id) references prize_deliveries (id) on delete CASCADE
) TABLESPACE pg_default;

create index IF not exists idx_delivery_status_history_delivery_id on public.delivery_status_history using btree (prize_delivery_id) TABLESPACE pg_default;

***************************************************************************************************************************
***************************************************************************************************************************
draws*

create table public.draws (
  id bigserial not null,
  draw_number integer not null,
  draw_date date not null,
  notes text null,
  created_at timestamp with time zone not null default now(),
  updated_at timestamp with time zone not null default now(),
  constraint draws_pkey primary key (id),
  constraint draws_draw_number_key unique (draw_number)
) TABLESPACE pg_default;

create index IF not exists idx_draws_draw_date on public.draws using btree (draw_date) TABLESPACE pg_default;

create trigger trg_draws_updated_at BEFORE
update on draws for EACH row
execute FUNCTION set_updated_at ();

***************************************************************************************************************************
***************************************************************************************************************************

fraud_signals

create table public.fraud_signals (
  id bigserial not null,
  signal_type text not null,
  phone_e164 text null,
  lot_code text null,
  details jsonb null,
  created_at timestamp with time zone not null default now(),
  constraint fraud_signals_pkey primary key (id)
) TABLESPACE pg_default;

***************************************************************************************************************************
***************************************************************************************************************************

lot_code_audit

create table public.lot_code_audit (
  id bigserial not null,
  phone_e164 text not null,
  raw_input text null,
  normalized_input text null,
  extracted_code text null,
  result_status text not null,
  product_type text null,
  coupons_generated integer not null default 0,
  reason text null,
  metadata jsonb null,
  created_at timestamp with time zone not null default now(),
  constraint lot_code_audit_pkey primary key (id)
) TABLESPACE pg_default;

create index IF not exists lot_code_audit_phone_idx on public.lot_code_audit using btree (phone_e164) TABLESPACE pg_default;

create index IF not exists lot_code_audit_created_idx on public.lot_code_audit using btree (created_at) TABLESPACE pg_default;

create index IF not exists lot_code_audit_code_idx on public.lot_code_audit using btree (extracted_code) TABLESPACE pg_default;

***************************************************************************************************************************
***************************************************************************************************************************

packs_progress

create table public.packs_progress (
  phone_e164 text not null,
  sachet_count integer not null default 0,
  status text not null default 'open'::text,
  updated_at timestamp with time zone not null default now(),
  constraint packs_progress_pkey primary key (phone_e164),
  constraint packs_progress_phone_e164_fkey foreign KEY (phone_e164) references users_wa (phone_e164) on delete CASCADE,
  constraint packs_progress_sachet_count_check check (
    (
      (sachet_count >= 0)
      and (sachet_count <= 10)
    )
  ),
  constraint packs_progress_status_check check (
    (
      status = any (array['open'::text, 'completed'::text])
    )
  )
) TABLESPACE pg_default;

***************************************************************************************************************************
***************************************************************************************************************************

prize_deliveries*

create table public.prize_deliveries (
  id bigserial not null,
  winner_prize_id bigint not null,
  delivery_status text not null default 'pending'::text,
  delivery_date date null,
  delivered_at timestamp with time zone null,
  agency text null,
  delivered_by text null,
  delivery_location text null,
  receiver_name text null,
  receiver_id text null,
  notes text null,
  evidence_url text null,
  signed_receipt_url text null,
  created_at timestamp with time zone not null default now(),
  updated_at timestamp with time zone not null default now(),
  constraint prize_deliveries_pkey primary key (id),
  constraint prize_deliveries_winner_prize_id_key unique (winner_prize_id),
  constraint prize_deliveries_winner_prize_id_fkey foreign KEY (winner_prize_id) references winner_prizes (id) on delete CASCADE,
  constraint prize_deliveries_delivery_status_check check (
    (
      delivery_status = any (
        array[
          'pending'::text,
          'contacted'::text,
          'scheduled'::text,
          'delivered'::text,
          'not_delivered'::text,
          'cancelled'::text
        ]
      )
    )
  )
) TABLESPACE pg_default;

create index IF not exists idx_prize_deliveries_status on public.prize_deliveries using btree (delivery_status) TABLESPACE pg_default;

***************************************************************************************************************************
***************************************************************************************************************************

user_daily_attempts

create table public.user_daily_attempts (
  phone_e164 text not null,
  day date not null,
  total_attempts integer not null default 0,
  invalid_attempts integer not null default 0,
  duplicate_attempts integer not null default 0,
  blocked_attempts integer not null default 0,
  constraint user_daily_attempts_pkey primary key (phone_e164, day)
) TABLESPACE pg_default;


***************************************************************************************************************************
***************************************************************************************************************************

*user_global_roles*

create table public.user_global_roles (
  id bigint generated always as identity not null,
  user_email text not null,
  role text not null,
  is_active boolean not null default true,
  created_at timestamp with time zone not null default now(),
  updated_at timestamp with time zone not null default now(),
  constraint user_global_roles_pkey primary key (id),
  constraint user_global_roles_user_email_key unique (user_email),
  constraint user_global_roles_role_check check ((role = 'admin'::text))
) TABLESPACE pg_default;

create trigger trg_set_updated_at_user_global_roles BEFORE
update on user_global_roles for EACH row
execute FUNCTION set_updated_at_user_global_roles ();


***************************************************************************************************************************
***************************************************************************************************************************

*user_module_roles*

create table public.user_module_roles (
  id bigint generated always as identity not null,
  user_email text not null,
  module text not null,
  role text not null,
  is_active boolean not null default true,
  created_at timestamp with time zone not null default now(),
  updated_at timestamp with time zone not null default now(),
  constraint user_module_roles_pkey primary key (id),
  constraint user_module_roles_unique unique (user_email, module),
  constraint user_module_roles_module_check check ((module = 'draws_prizes'::text)),
  constraint user_module_roles_role_check check (
    (
      role = any (array['viewer'::text, 'editor'::text])
    )
  )
) TABLESPACE pg_default;

create trigger trg_set_updated_at_user_module_roles BEFORE
update on user_module_roles for EACH row
execute FUNCTION set_updated_at_user_module_roles ();

***************************************************************************************************************************
***************************************************************************************************************************

user_rate_limit

create table public.user_rate_limit (
  phone_e164 text not null,
  last_attempt_at timestamp with time zone null,
  blocked_until timestamp with time zone null,
  updated_at timestamp with time zone not null default now(),
  constraint user_rate_limit_pkey primary key (phone_e164)
) TABLESPACE pg_default;


***************************************************************************************************************************
***************************************************************************************************************************

users_wa

create table public.users_wa (
  phone_e164 text not null,
  state text not null default 'CONSENT_PRIVACY'::text,
  name text null,
  cedula text null,
  email text null,
  location text null,
  consent_privacy boolean not null default false,
  consent_marketing boolean not null default false,
  created_at timestamp with time zone not null default now(),
  updated_at timestamp with time zone not null default now(),
  purchase_place text null,
  constraint users_wa_pkey primary key (phone_e164)
) TABLESPACE pg_default;

***************************************************************************************************************************
***************************************************************************************************************************

*vw_winners_detail*

create view public.vw_winners_detail as
select
  wp.id as winner_prize_id,
  u.name as nombre_completo,
  u.cedula as id_cedula,
  u.phone_e164 as telefono,
  wp.prize_name,
  wp.prize_type,
  wp.prize_amount,
  wp.currency,
  COALESCE(wp.city, u.location) as ciudad_zona,
  d.draw_date as fecha_sorteo,
  d.draw_number as no_sorteo,
  wp.status as estado_premio,
  pd.delivery_status as estado_entrega,
  pd.delivery_date as fecha_entrega,
  pd.agency as agencia,
  pd.delivered_by as entregado_por,
  pd.delivery_location,
  pd.receiver_name,
  pd.receiver_id,
  pd.evidence_url,
  pd.signed_receipt_url,
  wp.created_at
from
  winner_prizes wp
  join users_wa u on u.phone_e164 = wp.phone_e164
  join draws d on d.id = wp.draw_id
  left join prize_deliveries pd on pd.winner_prize_id = wp.id
order by
  d.draw_date desc,
  d.draw_number desc,
  wp.id desc;

***************************************************************************************************************************
***************************************************************************************************************************

*winner_prizes*

create table public.winner_prizes (
  id bigserial not null,
  phone_e164 text not null,
  draw_id bigint not null,
  prize_type text not null,
  prize_name text null,
  prize_amount numeric(12, 2) null,
  currency text not null default 'NIO'::text,
  city text null,
  zone text null,
  status text not null default 'pending'::text,
  source_coupon_id bigint null,
  source_code_entry_id bigint null,
  created_at timestamp with time zone not null default now(),
  updated_at timestamp with time zone not null default now(),
  constraint winner_prizes_pkey primary key (id),
  constraint winner_prizes_phone_e164_fkey foreign KEY (phone_e164) references users_wa (phone_e164) on update CASCADE,
  constraint winner_prizes_draw_id_fkey foreign KEY (draw_id) references draws (id) on delete RESTRICT,
  constraint winner_prizes_source_coupon_id_fkey foreign KEY (source_coupon_id) references coupons (id) on delete set null,
  constraint winner_prizes_source_code_entry_id_fkey foreign KEY (source_code_entry_id) references code_entries (id) on delete set null,
  constraint winner_prizes_status_check check (
    (
      status = any (
        array[
          'pending'::text,
          'scheduled'::text,
          'delivered'::text,
          'cancelled'::text,
          'expired'::text
        ]
      )
    )
  )
) TABLESPACE pg_default;

create index IF not exists idx_winner_prizes_phone on public.winner_prizes using btree (phone_e164) TABLESPACE pg_default;

create index IF not exists idx_winner_prizes_draw_id on public.winner_prizes using btree (draw_id) TABLESPACE pg_default;

create index IF not exists idx_winner_prizes_status on public.winner_prizes using btree (status) TABLESPACE pg_default;

create trigger trg_winner_prizes_updated_at BEFORE
update on winner_prizes for EACH row
execute FUNCTION set_updated_at ();

***************************************************************************************************************************
********************************************FUNCIONES**********************************************************************
***************************************************************************************************************************

ensure_user_wa

begin
  insert into public.users_wa(phone_e164)
  values (p_phone_e164)
  on conflict (phone_e164) do update
    set updated_at = now();

  return query
  select u.phone_e164, u.state
  from public.users_wa as u
  where u.phone_e164 = p_phone_e164;
end;

***************************************************************************************************************************
***************************************************************************************************************************

has_module_role

  select exists (
    select 1
    from public.user_module_roles umr
    where lower(umr.user_email) = lower(coalesce(auth.jwt() ->> 'email', ''))
      and umr.module = p_module
      and umr.role = any(p_roles)
      and umr.is_active = true
  );

***************************************************************************************************************************
***************************************************************************************************************************

is_global_admin

  select exists (
    select 1
    from public.user_global_roles ugr
    where lower(ugr.user_email) = lower(coalesce((select auth.jwt() ->> 'email'), ''))
      and ugr.role = 'admin'
      and ugr.is_active = true
  );

***************************************************************************************************************************
***************************************************************************************************************************

is_lmgomez_admin

  select (auth.jwt() ->> 'email') = 'lmgomez@gomezleemarketing.com';

***************************************************************************************************************************
***************************************************************************************************************************

log_delivery_status_change

declare
  v_email text;
  v_uid text;
  v_actor text;
  v_claims jsonb;
begin
  if old.delivery_status is not distinct from new.delivery_status then
    return new;
  end if;

  -- Intentar leer claims del request JWT
  begin
    v_claims := nullif(current_setting('request.jwt.claims', true), '')::jsonb;
  exception
    when others then
      v_claims := null;
  end;

  v_email := coalesce(
    v_claims ->> 'email',
    auth.jwt() ->> 'email'
  );

  v_uid := coalesce(
    v_claims ->> 'sub',
    auth.uid()::text
  );

  v_actor := coalesce(v_email, v_uid, session_user::text, 'unknown');

  insert into public.delivery_status_history (
    prize_delivery_id,
    old_status,
    new_status,
    changed_by,
    comment
  )
  values (
    new.id,
    old.delivery_status,
    new.delivery_status,
    v_actor,
    null
  );

  return new;
end;

***************************************************************************************************************************
***************************************************************************************************************************

log_lot_code_event

begin
  insert into public.lot_code_audit(
    phone_e164,
    raw_input,
    normalized_input,
    extracted_code,
    result_status,
    product_type,
    coupons_generated,
    reason,
    metadata
  )
  values (
    p_phone_e164,
    p_raw_input,
    p_normalized_input,
    p_extracted_code,
    p_result_status,
    p_product_type,
    coalesce(p_coupons_generated, 0),
    p_reason,
    coalesce(p_metadata, '{}'::jsonb)
  );
end;

***************************************************************************************************************************
***************************************************************************************************************************

register_lot_code

declare
  v_input text;
  v_code text;
  v_type text;
  v_today date := now()::date;
  v_now timestamptz := now();
  v_created int;
  v_new_count int;
  v_coupon_id bigint;
  v_same_code_count int;
  v_direct_coupon_qty int := 0;
  v_total_attempts int;
  v_invalid_attempts int;
  v_last_attempt_at timestamptz;
  v_blocked_until timestamptz;
  i int;
begin
  out_coupon_created := false;
  out_coupon_id := null;
  out_coupons_generated := 0;
  out_product_type := null;
  out_sachet_count := 0;
  out_remaining := 10;

  -- asegurar fila de rate limit
  insert into public.user_rate_limit(phone_e164, last_attempt_at, blocked_until)
  values (p_phone_e164, null, null)
  on conflict (phone_e164) do nothing;

  select last_attempt_at, blocked_until
  into v_last_attempt_at, v_blocked_until
  from public.user_rate_limit
  where phone_e164 = p_phone_e164
  for update;

  -- bloqueo temporal activo
  if v_blocked_until is not null and v_blocked_until > v_now then
    out_status := 'spam_blocked';

    perform public.log_lot_code_event(
      p_phone_e164,
      p_lot_code,
      null,
      null,
      'spam_blocked',
      null,
      0,
      'Usuario bloqueado temporalmente',
      jsonb_build_object('blocked_until', v_blocked_until)
    );

    insert into public.user_daily_attempts(phone_e164, day, blocked_attempts)
    values (p_phone_e164, v_today, 1)
    on conflict (phone_e164, day)
    do update set blocked_attempts = public.user_daily_attempts.blocked_attempts + 1;

    return next;
    return;
  end if;

  -- rate limit: 1 intento cada 3 segundos
  if v_last_attempt_at is not null and v_last_attempt_at > v_now - interval '3 seconds' then
    update public.user_rate_limit
    set blocked_until = v_now + interval '5 minutes',
        updated_at = v_now
    where phone_e164 = p_phone_e164;

    insert into public.fraud_signals(signal_type, phone_e164, details)
    values (
      'user_spam',
      p_phone_e164,
      jsonb_build_object('reason', 'too_fast', 'last_attempt_at', v_last_attempt_at)
    );

    out_status := 'spam_blocked';

    perform public.log_lot_code_event(
      p_phone_e164,
      p_lot_code,
      null,
      null,
      'spam_blocked',
      null,
      0,
      'Intentos demasiado rápidos',
      jsonb_build_object('last_attempt_at', v_last_attempt_at)
    );

    return next;
    return;
  end if;

  update public.user_rate_limit
  set last_attempt_at = v_now,
      updated_at = v_now
  where phone_e164 = p_phone_e164;

  -- asegurar contador diario
  insert into public.user_daily_attempts(phone_e164, day, total_attempts, invalid_attempts, duplicate_attempts, blocked_attempts)
  values (p_phone_e164, v_today, 0, 0, 0, 0)
  on conflict (phone_e164, day) do nothing;

  update public.user_daily_attempts
  set total_attempts = total_attempts + 1
  where phone_e164 = p_phone_e164 and day = v_today;

  select total_attempts, invalid_attempts
  into v_total_attempts, v_invalid_attempts
  from public.user_daily_attempts
  where phone_e164 = p_phone_e164 and day = v_today;

  -- límite duro diario de intentos
  if v_total_attempts > 60 then
    update public.user_rate_limit
    set blocked_until = v_now + interval '1 hour',
        updated_at = v_now
    where phone_e164 = p_phone_e164;

    insert into public.fraud_signals(signal_type, phone_e164, details)
    values (
      'suspicious_velocity',
      p_phone_e164,
      jsonb_build_object('total_attempts_today', v_total_attempts)
    );

    out_status := 'fraud_blocked';

    perform public.log_lot_code_event(
      p_phone_e164,
      p_lot_code,
      null,
      null,
      'fraud_blocked',
      null,
      0,
      'Exceso de intentos por día',
      jsonb_build_object('total_attempts_today', v_total_attempts)
    );

    return next;
    return;
  end if;

  -- Limpieza inicial
  v_input := upper(coalesce(p_lot_code, ''));
  v_input := regexp_replace(v_input, '^L\s*:\s*', '', 'g');
  v_input := regexp_replace(v_input, '^L\s+', '', 'g');
  v_input := regexp_replace(v_input, '\s+', '', 'g');

  -- Extraer solo el primer patrón válido de 10 caracteres
  v_code := substring(v_input from '([0-9]{4}[A-Z][0-9]{3}[CSMT][0-9])');

  if v_code is null then
    update public.user_daily_attempts
    set invalid_attempts = invalid_attempts + 1
    where phone_e164 = p_phone_e164 and day = v_today;

    select invalid_attempts
    into v_invalid_attempts
    from public.user_daily_attempts
    where phone_e164 = p_phone_e164 and day = v_today;

    if v_invalid_attempts >= 10 then
      update public.user_rate_limit
      set blocked_until = v_now + interval '1 hour',
          updated_at = v_now
      where phone_e164 = p_phone_e164;

      insert into public.fraud_signals(signal_type, phone_e164, details)
      values (
        'too_many_invalids',
        p_phone_e164,
        jsonb_build_object('invalid_attempts_today', v_invalid_attempts)
      );
    end if;

    out_status := 'invalid';

    perform public.log_lot_code_event(
      p_phone_e164,
      p_lot_code,
      v_input,
      null,
      'invalid',
      null,
      0,
      'No se pudo extraer un lote válido',
      jsonb_build_object('invalid_attempts_today', v_invalid_attempts)
    );

    return next;
    return;
  end if;

  -- Regla de negocio por terminación
  if right(v_code, 2) in ('C1','C2','C3','C4') then
    v_type := 'sachet';
    v_direct_coupon_qty := 0;
  elsif right(v_code, 2) in ('S4','M2','T1') then
    v_type := 'other';
    v_direct_coupon_qty := 5;
  else
    update public.user_daily_attempts
    set invalid_attempts = invalid_attempts + 1
    where phone_e164 = p_phone_e164 and day = v_today;

    out_status := 'invalid';

    perform public.log_lot_code_event(
      p_phone_e164,
      p_lot_code,
      v_input,
      v_code,
      'invalid',
      null,
      0,
      'Terminación no participante',
      '{}'::jsonb
    );

    return next;
    return;
  end if;

  -- señal anti-fraude: mismo lote desde muchos teléfonos en poco tiempo
  if (
    select count(distinct ce.phone_e164)
    from public.code_entries ce
    where ce.lot_code = v_code
      and ce.created_at >= v_now - interval '1 hour'
  ) >= 15 then
    insert into public.fraud_signals(signal_type, lot_code, details)
    values (
      'lot_spike',
      v_code,
      jsonb_build_object('window', '1 hour', 'reason', 'many phones')
    );
  end if;

  -- asegurar usuario
  insert into public.users_wa(phone_e164)
  values (p_phone_e164)
  on conflict (phone_e164) do update
    set updated_at = now();

  -- asegurar progreso
  insert into public.packs_progress(phone_e164)
  values (p_phone_e164)
  on conflict (phone_e164) do nothing;

  -- mismo código máximo 20 veces por usuario por día
  select count(*)::int
  into v_same_code_count
  from public.code_entries ce
  where ce.phone_e164 = p_phone_e164
    and ce.lot_code = v_code
    and ce.created_at::date = v_today;

  if v_same_code_count >= 20 then
    update public.user_daily_attempts
    set duplicate_attempts = duplicate_attempts + 1
    where phone_e164 = p_phone_e164 and day = v_today;

    select pp.sachet_count
    into v_new_count
    from public.packs_progress pp
    where pp.phone_e164 = p_phone_e164;

    out_status := 'duplicate';
    out_product_type := v_type;
    out_sachet_count := coalesce(v_new_count, 0);
    out_remaining := greatest(0, 10 - coalesce(v_new_count, 0));

    perform public.log_lot_code_event(
      p_phone_e164,
      p_lot_code,
      v_input,
      v_code,
      'duplicate',
      v_type,
      0,
      'Máximo 20 veces por día para el mismo lote',
      '{}'::jsonb
    );

    return next;
    return;
  end if;

  -- inicializar límite diario de cupones
  insert into public.daily_limits(phone_e164, day, coupons_created)
  values (p_phone_e164, v_today, 0)
  on conflict (phone_e164, day) do nothing;

  -- lock fila límite diario
  select dl.coupons_created
  into v_created
  from public.daily_limits dl
  where dl.phone_e164 = p_phone_e164
    and dl.day = v_today
  for update;

  -- registrar lote
  insert into public.code_entries(phone_e164, lot_code, product_type)
  values (p_phone_e164, v_code, v_type);

  -- M2/S4 = 5 cupones directos
  if v_type = 'other' then
    if v_created + v_direct_coupon_qty > 10 then
      select pp.sachet_count
      into v_new_count
      from public.packs_progress pp
      where pp.phone_e164 = p_phone_e164;

      out_status := 'limit';
      out_product_type := 'other';
      out_sachet_count := coalesce(v_new_count, 0);
      out_remaining := greatest(0, 10 - coalesce(v_new_count, 0));

      perform public.log_lot_code_event(
        p_phone_e164,
        p_lot_code,
        v_input,
        v_code,
        'limit',
        v_type,
        0,
        'Límite diario de cupones alcanzado',
        jsonb_build_object('current_daily_coupons', v_created)
      );

      return next;
      return;
    end if;

    for i in 1..v_direct_coupon_qty loop
      insert into public.coupons(phone_e164, coupon_type)
      values (p_phone_e164, 'direct_other')
      returning id into v_coupon_id;
    end loop;

    update public.daily_limits dl
    set coupons_created = dl.coupons_created + v_direct_coupon_qty
    where dl.phone_e164 = p_phone_e164
      and dl.day = v_today;

    select pp.sachet_count
    into v_new_count
    from public.packs_progress pp
    where pp.phone_e164 = p_phone_e164;

    out_status := 'ok';
    out_product_type := 'other';
    out_sachet_count := coalesce(v_new_count, 0);
    out_remaining := greatest(0, 10 - coalesce(v_new_count, 0));
    out_coupon_created := true;
    out_coupon_id := v_coupon_id;
    out_coupons_generated := v_direct_coupon_qty;

    perform public.log_lot_code_event(
      p_phone_e164,
      p_lot_code,
      v_input,
      v_code,
      'ok',
      v_type,
      out_coupons_generated,
      'Registro exitoso de lote other',
      '{}'::jsonb
    );

    return next;
    return;
  end if;

  -- Sachet = acumula hasta 10
  select pp.sachet_count
  into v_new_count
  from public.packs_progress pp
  where pp.phone_e164 = p_phone_e164
  for update;

  v_new_count := coalesce(v_new_count, 0) + 1;

  if v_new_count >= 10 then
    if v_created >= 10 then
      out_status := 'limit';
      out_product_type := 'sachet';
      out_sachet_count := 10;
      out_remaining := 0;

      perform public.log_lot_code_event(
        p_phone_e164,
        p_lot_code,
        v_input,
        v_code,
        'limit',
        v_type,
        0,
        'Límite diario de cupones alcanzado',
        jsonb_build_object('current_daily_coupons', v_created)
      );

      return next;
      return;
    end if;

    insert into public.coupons(phone_e164, coupon_type)
    values (p_phone_e164, 'sachet_10')
    returning id into v_coupon_id;

    update public.daily_limits dl
    set coupons_created = dl.coupons_created + 1
    where dl.phone_e164 = p_phone_e164
      and dl.day = v_today;

    update public.packs_progress pp
    set sachet_count = 0,
        status = 'open',
        updated_at = now()
    where pp.phone_e164 = p_phone_e164;

    out_status := 'ok';
    out_product_type := 'sachet';
    out_sachet_count := 10;
    out_remaining := 0;
    out_coupon_created := true;
    out_coupon_id := v_coupon_id;
    out_coupons_generated := 1;

    perform public.log_lot_code_event(
      p_phone_e164,
      p_lot_code,
      v_input,
      v_code,
      'ok',
      v_type,
      out_coupons_generated,
      'Cupón generado por completar 10 sachets',
      '{}'::jsonb
    );

    return next;
    return;
  else
    update public.packs_progress pp
    set sachet_count = v_new_count,
        status = 'open',
        updated_at = now()
    where pp.phone_e164 = p_phone_e164;

    out_status := 'ok';
    out_product_type := 'sachet';
    out_sachet_count := v_new_count;
    out_remaining := 10 - v_new_count;
    out_coupons_generated := 0;

    perform public.log_lot_code_event(
      p_phone_e164,
      p_lot_code,
      v_input,
      v_code,
      'ok',
      v_type,
      0,
      'Sachet acumulado correctamente',
      jsonb_build_object('current_sachet_count', v_new_count)
    );

    return next;
    return;
  end if;
end;

***************************************************************************************************************************
***************************************************************************************************************************

reset_user_wa


begin
  delete from public.code_entries where phone_e164 = p_phone;
  delete from public.packs_progress where phone_e164 = p_phone;
  delete from public.daily_limits where phone_e164 = p_phone;
  delete from public.coupons where phone_e164 = p_phone;
  delete from public.users_wa where phone_e164 = p_phone;
end;

***************************************************************************************************************************
***************************************************************************************************************************

restart_wa

declare
  v_priv boolean;
begin
  select u.consent_privacy into v_priv
  from public.users_wa u
  where u.phone_e164 = p_phone_e164;

  if coalesce(v_priv,false) = true then
    update public.users_wa
    set state = 'ASK_CODE', updated_at = now()
    where phone_e164 = p_phone_e164;
  else
    update public.users_wa
    set state = 'CONSENT_PRIVACY', updated_at = now()
    where phone_e164 = p_phone_e164;
  end if;

  return query
  select u.state from public.users_wa u where u.phone_e164 = p_phone_e164;
end;

***************************************************************************************************************************
***************************************************************************************************************************

set_consent_marketing_wa

begin
  update public.users_wa u
  set consent_marketing = p_accept,
      state = 'ASK_NAME',
      updated_at = now()
  where u.phone_e164 = p_phone_e164;

  return query
  select u.state
  from public.users_wa u
  where u.phone_e164 = p_phone_e164;
end;

***************************************************************************************************************************
***************************************************************************************************************************

set_consent_privacy_wa

begin
  update public.users_wa u
  set consent_privacy = p_accept,
      state = case when p_accept then 'CONSENT_MARKETING' else 'DONE' end,
      updated_at = now()
  where u.phone_e164 = p_phone_e164;

  return query
  select u.state
  from public.users_wa u
  where u.phone_e164 = p_phone_e164;
end;

***************************************************************************************************************************
***************************************************************************************************************************

set_coupon_code_before_insert


declare
  v_num bigint;
  v_num_text text;
begin
  if new.coupon_code is null then
    v_num := nextval('public.coupons_coupon_code_seq');
    v_num_text := v_num::text;

    if length(v_num_text) < 4 then
      v_num_text := lpad(v_num_text, 4, '0');
    end if;

    new.coupon_code := 'PRE-CUP-' || v_num_text;
  end if;

  return new;
end;

***************************************************************************************************************************
***************************************************************************************************************************

set_email_wa


declare
  v_email text;
begin
  v_email := nullif(lower(trim(coalesce(p_email,''))), '');

  update public.users_wa u
  set email = v_email,
      state = 'ASK_LOCATION',
      updated_at = now()
  where u.phone_e164 = p_phone_e164;

  return query
  select u.state
  from public.users_wa u
  where u.phone_e164 = p_phone_e164;
end;

***************************************************************************************************************************
***************************************************************************************************************************

set_id_wa


declare
  v_id text;
begin
  v_id := trim(coalesce(p_cedula,''));

  update public.users_wa u
  set cedula = v_id,
      state = 'ASK_EMAIL',
      updated_at = now()
  where u.phone_e164 = p_phone_e164;

  return query
  select u.state
  from public.users_wa u
  where u.phone_e164 = p_phone_e164;
end;

***************************************************************************************************************************
***************************************************************************************************************************

set_location_wa


declare
  v_loc text;
begin
  v_loc := trim(regexp_replace(coalesce(p_location,''), '\s+', ' ', 'g'));

  update public.users_wa u
  set location = v_loc,
      state = 'ASK_PURCHASE_PLACE',
      updated_at = now()
  where u.phone_e164 = p_phone_e164;

  return query
  select u.state
  from public.users_wa u
  where u.phone_e164 = p_phone_e164;
end;

***************************************************************************************************************************
***************************************************************************************************************************

set_name_wa

declare
  v_name text;
begin
  -- Limpieza básica
  v_name := trim(regexp_replace(coalesce(p_name,''), '\s+', ' ', 'g'));

  update public.users_wa u
  set name = v_name,
      state = 'ASK_ID',
      updated_at = now()
  where u.phone_e164 = p_phone_e164;

  return query
  select u.state
  from public.users_wa u
  where u.phone_e164 = p_phone_e164;
end;

***************************************************************************************************************************
***************************************************************************************************************************

set_purchase_place_wa


declare
  v_place text;
begin
  v_place := trim(regexp_replace(coalesce(p_purchase_place,''), '\s+', ' ', 'g'));

  update public.users_wa u
  set purchase_place = v_place,
      state = 'ASK_CODE',
      updated_at = now()
  where u.phone_e164 = p_phone_e164;

  return query
  select u.state
  from public.users_wa u
  where u.phone_e164 = p_phone_e164;
end;

***************************************************************************************************************************
***************************************************************************************************************************

set_state_wa

begin
  update public.users_wa u
  set state = p_state,
      updated_at = now()
  where u.phone_e164 = p_phone_e164;

  return query select u.state from public.users_wa u where u.phone_e164 = p_phone_e164;
end;

***************************************************************************************************************************
***************************************************************************************************************************

set_updated_at

begin
  new.updated_at = now();
  return new;
end;

***************************************************************************************************************************
***************************************************************************************************************************

set_updated_at_user_global_roles

begin
  new.updated_at = now();
  return new;
end;

set_updated_at_user_module_roles

begin
  new.updated_at = now();
  return new;
end;

***************************************************************************************************************************
***************************************************************************************************************************

whoami

  select json_build_object(
    'role', auth.role(),
    'email', auth.jwt() ->> 'email',
    'sub', auth.uid()
  );

